From 2bceae5be075aff241439fbb5f450b4ea7eab19b Mon Sep 17 00:00:00 2001 From: Noah Prince <83885631+ChewingGlass@users.noreply.github.com> Date: Wed, 16 Oct 2024 09:41:18 +0900 Subject: [PATCH] HIP-110: Proxy voting (#154) --------- Co-authored-by: bry Co-authored-by: Abhay Kumar --- README.md | 124 + bin/helium-vote.ts | 2 +- next-env.d.ts | 2 +- next.config.mjs | 92 +- package.json | 47 +- public/images/hntWhite.svg | 5 + public/images/iotWhite.svg | 3 + public/images/mobileWhite.svg | 3 + scripts/bootstrap.ts | 1 + scripts/create-proposal.ts | 1 + scripts/resolve-proposals.ts | 1 + .../proposals/realm/[proposalKey]/page.tsx | 1 - src/app/[network]/proxies/[wallet]/page.tsx | 56 + src/app/[network]/proxies/page.tsx | 28 + src/app/globals.css | 4 + src/components/AssignProxyModal.tsx | 247 + src/components/CreatePositionModal.tsx | 78 +- src/components/ExpirationTimeSlider.tsx | 27 + src/components/Header.tsx | 140 +- src/components/LegacyProposal.tsx | 200 +- src/components/LockTokensForm.tsx | 3 +- src/components/Markdown.tsx | 67 + src/components/NetworkSelect.tsx | 46 + src/components/NetworkTabs.tsx | 6 +- src/components/Pill.tsx | 2 + src/components/PositionCard.tsx | 237 +- .../PositionActionBoundary.tsx | 4 +- .../PositionManager/PositionCallout.tsx | 72 +- .../PositionManager/PositionManager.tsx | 147 +- .../PositionManager/ProxyPositionPrompt.tsx | 157 + src/components/PositionPreview.tsx | 71 + src/components/Positions.tsx | 79 +- src/components/Proposal.tsx | 550 +- src/components/Proposals.tsx | 10 +- src/components/Proxies.tsx | 203 + src/components/ProxyButton.tsx | 50 + src/components/ProxyProfile.tsx | 346 + src/components/ProxySearch.tsx | 73 + src/components/RealmProposal.tsx | 142 +- src/components/RevokeProxyButton.tsx | 58 + src/components/RevokeProxyModal.tsx | 167 + src/components/SubNav.tsx | 111 + src/components/VeTokensCallout.tsx | 59 +- src/components/ViewPositionsButton.tsx | 23 - src/components/VoteBreakdown.tsx | 51 +- src/components/VoteHistory.tsx | 169 + src/components/VoteOption.tsx | 80 +- src/components/VoteOptions.tsx | 92 +- src/components/ui/autocomplete.tsx | 179 + src/components/ui/button.tsx | 11 +- src/components/ui/command.tsx | 155 + src/components/ui/dropdown-menu.tsx | 202 + src/components/ui/popover.tsx | 31 + src/components/ui/select.tsx | 160 + src/components/ui/slider.tsx | 28 + src/components/ui/toggle-group.tsx | 61 + src/components/ui/toggle.tsx | 46 + src/hooks/useMetaplexMetadata.ts | 131 + src/hooks/useNetwork.ts | 31 + src/hooks/useProposalInfo.ts | 69 + src/hooks/useProposalStatus.ts | 89 + src/lib/constants.ts | 9 + src/lib/dateTools.ts | 70 + src/lib/utils.ts | 34 +- src/providers/GovernanceProvider.tsx | 11 +- src/providers/providers.tsx | 32 +- tailwind.config.ts | 5 + tsconfig.json | 1 + yarn.lock | 8869 ----------------- 69 files changed, 4525 insertions(+), 9836 deletions(-) create mode 100644 public/images/hntWhite.svg create mode 100644 public/images/iotWhite.svg create mode 100644 public/images/mobileWhite.svg create mode 100644 src/app/[network]/proxies/[wallet]/page.tsx create mode 100644 src/app/[network]/proxies/page.tsx create mode 100644 src/components/AssignProxyModal.tsx create mode 100644 src/components/ExpirationTimeSlider.tsx create mode 100644 src/components/Markdown.tsx create mode 100644 src/components/NetworkSelect.tsx create mode 100644 src/components/PositionManager/ProxyPositionPrompt.tsx create mode 100644 src/components/PositionPreview.tsx create mode 100644 src/components/Proxies.tsx create mode 100644 src/components/ProxyButton.tsx create mode 100644 src/components/ProxyProfile.tsx create mode 100644 src/components/ProxySearch.tsx create mode 100644 src/components/RevokeProxyButton.tsx create mode 100644 src/components/RevokeProxyModal.tsx create mode 100644 src/components/SubNav.tsx delete mode 100644 src/components/ViewPositionsButton.tsx create mode 100644 src/components/VoteHistory.tsx create mode 100644 src/components/ui/autocomplete.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx create mode 100644 src/hooks/useMetaplexMetadata.ts create mode 100644 src/hooks/useNetwork.ts create mode 100644 src/hooks/useProposalInfo.ts create mode 100644 src/hooks/useProposalStatus.ts create mode 100644 src/lib/dateTools.ts delete mode 100644 yarn.lock diff --git a/README.md b/README.md index 3048acf..50905ac 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,127 @@ A straw poll website that enables a simple straw-poll for Helium related initiat * A block height deadline is set for the tally to be taken. * Votes are tallied by the HNT voting "power" of the account. One HNT = 1 Vote. * DCs sent to an outcome address will be flushed from the system after voting is complete. + +## Developing Locally (with proxies) + +### 1. Localnet from helium-program-library + +Clone helium-program-library. Run + +``` +anchor localnet +``` + +Then run + +``` +./scripts/bootstrap.sh +``` + +This will create a bunch of keypairs in packages/helium-admin-cli/keypairs. You will need to get the addresses of the HNT, IOT, and MOBILE tokens it created. Then update constants.js in `spl-utils`. + +### 2. Make sure modgov idls deployed + +Clone modular-governance. + +``` +./scripts/upgrade-idls.sh +``` + +### 3. Bootstrap the Helium DAO + +In this repo + +``` +./bin/helium-vote.ts bootstrap --name Helium --mint APqAVo5q9erS8GaXcbJuy3Gx4ikuSzXjzY4SnyppPUm1 --authority $(solana address) +``` + +### 4. Create a proposal + +``` +./bin/helium-vote.ts create-proposal --name HIP 110: Proxy Voting --proposalUri https://gist.githubusercontent.com/hiptron/4404ea1e78ed8c92ba5001df45740386/raw/88adb21a40475dd7b6673e816492357fb425c72a/HIP-110-HNT-Vote-Summary.md --orgName Helium +``` + +### 5. Run the account-postgres-sink for helium-vote-service + +Make sure you have a postgres running. I just have a script to run it via docker: + +``` +#!/bin/bash + +docker volume create -o size=10GB pgdata +docker run --shm-size=1g -it --rm -p 5432:5432 --name postgres -e POSTGRES_PASSWORD=postgres -d -v pgdata:/var/lib/postgresql/data postgres:latest + +docker logs -f postgres +``` + +In helium-program-library, cd `packages/account-postgres-sink-service` + +Update the `.env` to the following: + +``` +SOLANA_URL=http://localhost:8899 +PGUSER=postgres +PGPASSWORD=postgres +USE_SUBSTREAMS=false +PHOTON_URL=https://photon.komoot.io +USE_KAFKA=false +PROGRAM_ACCOUNT_CONFIGS=/path/to/helium-program-library/packages/account-postgres-sink-service/vote_service_example.json + +``` + +Making sure to update the path to the `vote_service_example.json` + +Run + +``` +yarn dev +``` + +Then, navigate to `localhost:3000/refresh-accounts` + +Every time you make a change to proxies, you will need to re-hit-this endpoint. + +### 6. Run the helium-vote service. + +cd into helium-program-library/packages/helium-vote-service + +Update the .env to: + +``` +PGDATABASE=postgres +PGUSER=postgres +PGPASSWORD=postgres +``` + +Then: + +``` +yarn dev +``` + +### 7. Run helium-vote on port 3001 + +In this repo, + +``` +env PORT=3001 yarn dev +``` + +Note that if you're using yalc, it's useful to just run: + +``` +yalc update && rm -rf .next/cache && env PORT=3001 yarn dev +``` + + +### 8. Fund any wallets you're using for testing + +``` +solana transfer -u http://localhost:8899 exmrL4U6vk6VFoh3Q7fkrPbjpNLHPYFf1J8bqGypuiK 10 --allow-unfunded-recipient +``` + +Make sure to transfer some fake HNT to whatever wallet you plan to stake with. +``` +spl-token transfer -u http://localhost:8899 100 --allow-unfunded-recipient --fund-recipient +``` diff --git a/bin/helium-vote.ts b/bin/helium-vote.ts index 4638214..882aa03 100755 --- a/bin/helium-vote.ts +++ b/bin/helium-vote.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env ts-node +#!/usr/bin/env npx ts-node const { hideBin } = require("yargs/helpers"); diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03..40c3d68 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index 3a456fa..f9cce5b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -15,6 +15,26 @@ const nextConfig = { port: "", pathname: "/**/**", }, + { + protocol: "https", + hostname: "helium.io", + port: "", + pathname: "/**/**", + }, + { + protocol: "https", + hostname: "*.test-helium.com", + port: "", + pathname: "/**/**", + }, + ...(process.env.NODE_ENV === "development" ? [ + { + protocol: "http", + hostname: "localhost", + port: "8082", + pathname: "/**/**", + }, + ] : []), ], }, logging: { @@ -1272,122 +1292,146 @@ const nextConfig = { }, { source: "/147pXvGVcLKU76D7Hdi7iTTLvLw9qX8jXsCgNkKSHqo3rnfzkby", - destination: "/legacy/147pXvGVcLKU76D7Hdi7iTTLvLw9qX8jXsCgNkKSHqo3rnfzkby", + destination: + "/legacy/147pXvGVcLKU76D7Hdi7iTTLvLw9qX8jXsCgNkKSHqo3rnfzkby", permanent: true, }, { source: "/14rmHKjpZhsnrA2j24KiGzg8teA1zAMRhVGcWVUdmTAomTrJJQf", - destination: "/legacy/14rmHKjpZhsnrA2j24KiGzg8teA1zAMRhVGcWVUdmTAomTrJJQf", + destination: + "/legacy/14rmHKjpZhsnrA2j24KiGzg8teA1zAMRhVGcWVUdmTAomTrJJQf", permanent: true, }, { source: "/146pksPcH7C3Wz8hN5NxL544k1VVq6Z4W2iB14e1yJ1HFJ3Wtf3", - destination: "/legacy/146pksPcH7C3Wz8hN5NxL544k1VVq6Z4W2iB14e1yJ1HFJ3Wtf3", + destination: + "/legacy/146pksPcH7C3Wz8hN5NxL544k1VVq6Z4W2iB14e1yJ1HFJ3Wtf3", permanent: true, }, { source: "/144wSVHr4cuVSjxEC62X1bHuLsc5Hcpq6XBhfSY8BQwiyMborFZ", - destination: "/legacy/144wSVHr4cuVSjxEC62X1bHuLsc5Hcpq6XBhfSY8BQwiyMborFZ", + destination: + "/legacy/144wSVHr4cuVSjxEC62X1bHuLsc5Hcpq6XBhfSY8BQwiyMborFZ", permanent: true, }, { source: "/143vgVpLgC3LcXLZCyXYZiHCFcsH9UNz3vR8CL6SDxTLPL5tWtr", - destination: "/legacy/143vgVpLgC3LcXLZCyXYZiHCFcsH9UNz3vR8CL6SDxTLPL5tWtr", + destination: + "/legacy/143vgVpLgC3LcXLZCyXYZiHCFcsH9UNz3vR8CL6SDxTLPL5tWtr", permanent: true, }, { source: "/14jH67zhctwb3B5NmwiAjaXQuyF7jZMZCAnfyBYhSpRS3L22sQE", - destination: "/legacy/14jH67zhctwb3B5NmwiAjaXQuyF7jZMZCAnfyBYhSpRS3L22sQE", + destination: + "/legacy/14jH67zhctwb3B5NmwiAjaXQuyF7jZMZCAnfyBYhSpRS3L22sQE", permanent: true, }, { source: "/13Z3p82AX8H1EUQF74cP2qq7RmmhbKBAiGWWsyA7WLxQ9NENTVW", - destination: "/legacy/13Z3p82AX8H1EUQF74cP2qq7RmmhbKBAiGWWsyA7WLxQ9NENTVW", + destination: + "/legacy/13Z3p82AX8H1EUQF74cP2qq7RmmhbKBAiGWWsyA7WLxQ9NENTVW", permanent: true, }, { source: "/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", - destination: "/legacy/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", + destination: + "/legacy/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", permanent: true, }, { source: "/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", - destination: "/legacy/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", + destination: + "/legacy/13Y79HnMVt4Epug2rbLYivMPvrcpC2wYUNbZK5Dga3tw3VBpBjk", permanent: true, }, { source: "/13KaGoC2ED8kEh2sXLZ7eGWrqDUMyFH5k48VQ3LLjU5QoQidMV4", - destination: "/legacy/13KaGoC2ED8kEh2sXLZ7eGWrqDUMyFH5k48VQ3LLjU5QoQidMV4", + destination: + "/legacy/13KaGoC2ED8kEh2sXLZ7eGWrqDUMyFH5k48VQ3LLjU5QoQidMV4", permanent: true, }, { source: "/13rA4AXq5ME5s9FEyZrE4BMjxiAF9W3kU2tnmUH8FkFGsV97jEp", - destination: "/legacy/13rA4AXq5ME5s9FEyZrE4BMjxiAF9W3kU2tnmUH8FkFGsV97jEp", + destination: + "/legacy/13rA4AXq5ME5s9FEyZrE4BMjxiAF9W3kU2tnmUH8FkFGsV97jEp", permanent: true, }, { source: "/14cXnMdXYcS7WKNh33dYUaPeo8bZmRFn5AoPA78AZtAgfFXu9jf", - destination: "/legacy/14cXnMdXYcS7WKNh33dYUaPeo8bZmRFn5AoPA78AZtAgfFXu9jf", + destination: + "/legacy/14cXnMdXYcS7WKNh33dYUaPeo8bZmRFn5AoPA78AZtAgfFXu9jf", permanent: true, }, { source: "/14KhDJUdvAXNVVP5m5cqEaLGNC859sXvpHtxWX9r999pZKC8xAs", - destination: "/legacy/14KhDJUdvAXNVVP5m5cqEaLGNC859sXvpHtxWX9r999pZKC8xAs", + destination: + "/legacy/14KhDJUdvAXNVVP5m5cqEaLGNC859sXvpHtxWX9r999pZKC8xAs", permanent: true, }, { source: "/13UrtNApGd3NbeP3NyyTejqNEPAv3NGxkGjvtwTVdaRs24NT7Wy", - destination: "/legacy/13UrtNApGd3NbeP3NyyTejqNEPAv3NGxkGjvtwTVdaRs24NT7Wy", + destination: + "/legacy/13UrtNApGd3NbeP3NyyTejqNEPAv3NGxkGjvtwTVdaRs24NT7Wy", permanent: true, }, { source: "/14Rjhhz1DXLVmSRdzappqWgD6rfgu6XYxmdaSCvWLyLH8ZWbciK", - destination: "/legacy/14Rjhhz1DXLVmSRdzappqWgD6rfgu6XYxmdaSCvWLyLH8ZWbciK", + destination: + "/legacy/14Rjhhz1DXLVmSRdzappqWgD6rfgu6XYxmdaSCvWLyLH8ZWbciK", permanent: true, }, { source: "/14me3X7jpEmn3eeFfnAkMvUoFU3cN6GAS3CDomqCikr7VQfHWrU", - destination: "/legacy/14me3X7jpEmn3eeFfnAkMvUoFU3cN6GAS3CDomqCikr7VQfHWrU", + destination: + "/legacy/14me3X7jpEmn3eeFfnAkMvUoFU3cN6GAS3CDomqCikr7VQfHWrU", permanent: true, }, { source: "/14hfi6Vs9YmwYLwVHKygqyEqwTERRrx5kfQkVoX1uqMTxiE5EgJ", - destination: "/legacy/14hfi6Vs9YmwYLwVHKygqyEqwTERRrx5kfQkVoX1uqMTxiE5EgJ", + destination: + "/legacy/14hfi6Vs9YmwYLwVHKygqyEqwTERRrx5kfQkVoX1uqMTxiE5EgJ", permanent: true, }, { source: "/14XDEkg1t398kvqvgxMMKH8qzVGNBb1mgHhTjNmc5KkC3XJxu8p", - destination: "/legacy/14XDEkg1t398kvqvgxMMKH8qzVGNBb1mgHhTjNmc5KkC3XJxu8p", + destination: + "/legacy/14XDEkg1t398kvqvgxMMKH8qzVGNBb1mgHhTjNmc5KkC3XJxu8p", permanent: true, }, { source: "/14rifUhocpzdwsrWaG5PDbdREDkzyesKe1hXuWzibv8h9DdqKLe", - destination: "/legacy/14rifUhocpzdwsrWaG5PDbdREDkzyesKe1hXuWzibv8h9DdqKLe", + destination: + "/legacy/14rifUhocpzdwsrWaG5PDbdREDkzyesKe1hXuWzibv8h9DdqKLe", permanent: true, }, { source: "/13NyqFtVKsifrh6HQ7DjSBKXRDi7qLHDoATHogoSgvBh56oZJv8", - destination: "/legacy/13NyqFtVKsifrh6HQ7DjSBKXRDi7qLHDoATHogoSgvBh56oZJv8", + destination: + "/legacy/13NyqFtVKsifrh6HQ7DjSBKXRDi7qLHDoATHogoSgvBh56oZJv8", permanent: true, }, { source: "/14MnuexopPfDg3bmq8JdCm7LMDkUBoqhqanD9QzLrUURLZxFHBx", - destination: "/legacy/14MnuexopPfDg3bmq8JdCm7LMDkUBoqhqanD9QzLrUURLZxFHBx", + destination: + "/legacy/14MnuexopPfDg3bmq8JdCm7LMDkUBoqhqanD9QzLrUURLZxFHBx", permanent: true, }, { source: "/13wCuq7XGnc4xgxPAc9n9ragKsRfmH9t9jB3c1smfKPZWSikZkd", - destination: "/legacy/13wCuq7XGnc4xgxPAc9n9ragKsRfmH9t9jB3c1smfKPZWSikZkd", + destination: + "/legacy/13wCuq7XGnc4xgxPAc9n9ragKsRfmH9t9jB3c1smfKPZWSikZkd", permanent: true, }, { source: "/14iwaexUYUe5taFgb5hx2BZw74z3TSyonRLYyZU1RbddV4bJest", - destination: "/legacy/14iwaexUYUe5taFgb5hx2BZw74z3TSyonRLYyZU1RbddV4bJest", + destination: + "/legacy/14iwaexUYUe5taFgb5hx2BZw74z3TSyonRLYyZU1RbddV4bJest", permanent: true, }, { source: "/13F5AWLhxwTjhDMnZ6ww4oJWgRkBoW9ji4JnDzwQXjsQhiT2kcX", - destination: "/legacy/13F5AWLhxwTjhDMnZ6ww4oJWgRkBoW9ji4JnDzwQXjsQhiT2kcX", + destination: + "/legacy/13F5AWLhxwTjhDMnZ6ww4oJWgRkBoW9ji4JnDzwQXjsQhiT2kcX", permanent: true, }, ]; diff --git a/package.json b/package.json index d7b85ae..756259d 100644 --- a/package.json +++ b/package.json @@ -10,26 +10,32 @@ }, "dependencies": { "@coral-xyz/anchor": "^0.29.0", - "@helium/account-fetch-cache": "^0.7.11", - "@helium/account-fetch-cache-hooks": "^0.7.11", - "@helium/helium-react-hooks": "^0.7.11", - "@helium/modular-governance-hooks": "^0.0.8", - "@helium/modular-governance-idls": "^0.0.8-next", - "@helium/organization-sdk": "^0.0.8", - "@helium/spl-utils": "^0.7.11", - "@helium/state-controller-sdk": "^0.0.8", - "@helium/voter-stake-registry-hooks": "^0.7.11", - "@helium/voter-stake-registry-sdk": "^0.7.11", + "@helium/account-fetch-cache": "^0.9.7", + "@helium/account-fetch-cache-hooks": "^0.9.7", + "@helium/helium-react-hooks": "^0.9.7", + "@helium/modular-governance-hooks": "^0.0.13", + "@helium/modular-governance-idls": "^0.0.13", + "@helium/organization-sdk": "^0.0.13", + "@helium/spl-utils": "^0.9.7", + "@helium/state-controller-sdk": "^0.0.13", + "@helium/voter-stake-registry-hooks": "^0.9.7", + "@helium/voter-stake-registry-sdk": "^0.9.7", "@hookform/resolvers": "^3.3.4", "@metaplex-foundation/mpl-token-metadata": "2.10.0", "@project-serum/anchor": "^0.26.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", "@solana/spl-token": "^0.4.0", "@solana/wallet-adapter-base": "^0.9.23", "@solana/wallet-adapter-react": "^0.15.35", @@ -37,11 +43,14 @@ "@solana/wallet-adapter-wallets": "^0.19.32", "@solana/web3.js": "^1.90.0", "@sqds/sdk": "^2.0.4", + "@tanstack/react-query": "5.45.1", + "@uidotdev/usehooks": "^2.4.1", "axios": "^1.6.7", "bn.js": "^5.2.1", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "clsx": "^2.1.0", + "cmdk": "^1.0.0", "date-fns": "^3.3.1", "lucide-react": "^0.323.0", "markdown-it": "^14.0.0", @@ -53,6 +62,7 @@ "react-countdown": "^2.3.5", "react-dom": "^18", "react-icons": "^5.0.1", + "react-infinite-scroll-component": "^6.1.0", "react-markdown": "^9.0.1", "sonner": "^1.4.0", "tailwind-merge": "^2.2.1", @@ -61,14 +71,17 @@ "yargs": "^17.7.2" }, "resolutions": { + "@tanstack/react-query": "5.45.1", "@solana/web3.js": "^1.90.0", - "@helium/account-fetch-cache": "^0.7.11", - "@helium/account-fetch-cache-hooks": "^0.7.11", - "@helium/helium-react-hooks": "^0.7.11", - "@helium/voter-stake-registry-hooks": "^0.7.11", - "@helium/spl-utils": "^0.7.11", - "@helium/modular-governance-hooks": "^0.0.8", - "@solana/wallet-adapter-react": "^0.15.35" + "@helium/account-fetch-cache": "^0.9.7", + "@helium/account-fetch-cache-hooks": "^0.9.7", + "@helium/helium-react-hooks": "^0.9.7", + "@helium/voter-stake-registry-hooks": "^0.9.7", + "@helium/modular-governance-idls": "^0.0.13", + "@helium/spl-utils": "^0.9.7", + "@helium/modular-governance-hooks": "^0.0.13", + "@solana/wallet-adapter-react": "^0.15.35", + "@helium/voter-stake-registry-sdk": "^0.9.7" }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", diff --git a/public/images/hntWhite.svg b/public/images/hntWhite.svg new file mode 100644 index 0000000..cddf61f --- /dev/null +++ b/public/images/hntWhite.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/iotWhite.svg b/public/images/iotWhite.svg new file mode 100644 index 0000000..f9cea50 --- /dev/null +++ b/public/images/iotWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/mobileWhite.svg b/public/images/mobileWhite.svg new file mode 100644 index 0000000..0e24e8b --- /dev/null +++ b/public/images/mobileWhite.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index 4df3e66..c591482 100644 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -121,6 +121,7 @@ export async function run(args: any = process.argv) { voteController, stateController: resolutionSettings!, onVoteHook: stateProgram.programId, + authority: wallet.publicKey, } ); const proposalConfig = (await initProposalConfig.pubkeys()).proposalConfig!; diff --git a/scripts/create-proposal.ts b/scripts/create-proposal.ts index 51a3c60..2f322c9 100644 --- a/scripts/create-proposal.ts +++ b/scripts/create-proposal.ts @@ -98,6 +98,7 @@ export async function run(args: any = process.argv) { tags: ["test", "tags"], }) .accounts({ + payer: authority, authority, organization: organizationK, owner: authority, diff --git a/scripts/resolve-proposals.ts b/scripts/resolve-proposals.ts index 4d5b35b..2e1724b 100644 --- a/scripts/resolve-proposals.ts +++ b/scripts/resolve-proposals.ts @@ -37,6 +37,7 @@ export async function run(args: any = process.argv) { connection: provider.connection, commitment: "confirmed", extendConnection: true, + enableLogging: true }); const orgProgram = await initOrg(provider); const stateProgram = await initState(provider); diff --git a/src/app/[network]/proposals/realm/[proposalKey]/page.tsx b/src/app/[network]/proposals/realm/[proposalKey]/page.tsx index 21df4cd..a3cb455 100644 --- a/src/app/[network]/proposals/realm/[proposalKey]/page.tsx +++ b/src/app/[network]/proposals/realm/[proposalKey]/page.tsx @@ -36,7 +36,6 @@ const RealmProposalPage = async ({ const proposal: IRealmProposal = fetchRealmProposal(proposalKey); const content = await getContent(proposal.gist); - console.log(content); return ( <>
diff --git a/src/app/[network]/proxies/[wallet]/page.tsx b/src/app/[network]/proxies/[wallet]/page.tsx new file mode 100644 index 0000000..01a98ab --- /dev/null +++ b/src/app/[network]/proxies/[wallet]/page.tsx @@ -0,0 +1,56 @@ +import { Header } from "@/components/Header"; +import { ProxyProfile } from "@/components/ProxyProfile"; +import { networksToMint } from "@helium/spl-utils"; +import { + proxyQuery +} from "@helium/voter-stake-registry-hooks"; +import { VoteService, getRegistrarKey } from "@helium/voter-stake-registry-sdk"; +import { PublicKey } from "@solana/web3.js"; +import { HydrationBoundary, QueryClient, dehydrate, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { notFound } from "next/navigation"; + +function getVoteService({ mint }: { mint: PublicKey }) { + const registrar = getRegistrarKey(mint); + return new VoteService({ + baseURL: process.env.NEXT_PUBLIC_HELIUM_VOTE_URI, + registrar, + }); +} + +export default async function ProxyPage({ + params: { wallet: walletRaw, network }, +}: { + params: { wallet: string; network: string }; +}) { + const voteService = getVoteService({ mint: networksToMint[network] }); + const wallet = new PublicKey(walletRaw); + const queryClient = new QueryClient(); + + // Do not use prefetch here, because we want to error when it errors + const result = await queryClient.fetchQuery( + // @ts-ignore + proxyQuery({ + wallet, + voteService, + }) + ); + + + if (!result) { + return notFound(); + } + + const dehydrated = dehydrate( + queryClient, + ); + + return ( + <> +
+ + + + + ); +} diff --git a/src/app/[network]/proxies/page.tsx b/src/app/[network]/proxies/page.tsx new file mode 100644 index 0000000..717dcb0 --- /dev/null +++ b/src/app/[network]/proxies/page.tsx @@ -0,0 +1,28 @@ +import { Header } from "@/components/Header"; +import { Proxies } from "@/components/Proxies"; +import { formMetaTags } from "@/lib/utils"; + +export interface VotersPageParams { + params: { + network: string; + }; + searchParams: Record | null | undefined; +} + +export const generateMetadata = async ({ params }: VotersPageParams) => { + const { network } = params; + + return formMetaTags({ + title: `${network.toUpperCase()} Proxies`, + url: `https://heliumvote.com/${network}/proxies`, + }); +}; + +export default async function PositionsPage() { + return ( + <> +
+ + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 1370407..abe4e9f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -28,6 +28,8 @@ --info-foreground: 217 91% 60%; --purple: 274 87 21%; --purple-foreground: 258 90 66%; + --pink: 336 84 17%; + --pink-foreground: 330 81 60%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; @@ -59,6 +61,8 @@ --info-foreground: 217 91% 60%; --purple: 274 87 21%; --purple-foreground: 258 90 66%; + --pink: 336 84 17%; + --pink-foreground: 330 81 60%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 224.3 76.3% 48%; diff --git a/src/components/AssignProxyModal.tsx b/src/components/AssignProxyModal.tsx new file mode 100644 index 0000000..0fb1884 --- /dev/null +++ b/src/components/AssignProxyModal.tsx @@ -0,0 +1,247 @@ +import { useGovernance } from "@/providers/GovernanceProvider"; +import { PositionWithMeta } from "@helium/voter-stake-registry-hooks"; +import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; +import { Loader2 } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { ExpirationTimeSlider } from "./ExpirationTimeSlider"; +import { NetworkSelect } from "./NetworkSelect"; +import { PositionPreview } from "./PositionPreview"; +import { ProxySearch } from "./ProxySearch"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; + +interface AssignProxyModalProps { + onSubmit: (args: { + positions: PositionWithMeta[]; + recipient: PublicKey; + expirationTime: BN; + }) => Promise; + wallet?: PublicKey; +} + +export const AssignProxyModal: React.FC< + React.PropsWithChildren +> = ({ onSubmit, wallet, children }) => { + const [open, setOpen] = useState(false); + const { network: networkDefault } = useGovernance(); + const [network, setNetwork] = useState(networkDefault); + + const { loading, positions } = useGovernance(); + const [selectedPositions, setSelectedPositions] = useState>( + new Set() + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const unproxiedPositions = useMemo( + () => + positions?.filter( + (p) => !p.proxy || p.proxy.nextVoter.equals(PublicKey.default) + ) || [], + [positions] + ); + const today = new Date(); + const augustFirst = Date.UTC( + today.getMonth() >= 7 ? today.getFullYear() + 1 : today.getFullYear(), + 7, + 1 + ); + const maxDate = Math.min( + augustFirst - 1000, + ...unproxiedPositions + .filter((p) => selectedPositions.has(p.pubkey.toBase58()) && p.proxy) + // @ts-ignore + .map((p) => p.proxy.expirationTime.toNumber() * 1000) + ); + const maxDays = Math.floor( + (maxDate - today.getTime()) / (1000 * 60 * 60 * 24) + ); + const [selectedDays, setSelectedDays] = useState(maxDays); + const [recipient, setRecipient] = useState(wallet?.toBase58() || ""); + const expirationTime = useMemo( + () => + selectedDays === maxDays + ? maxDate.valueOf() / 1000 + : new Date().valueOf() / 1000 + selectedDays * (24 * 60 * 60), + [selectedDays, maxDays, maxDate] + ); + + const handleSelectedDays = (days: number) => { + setSelectedDays(days > maxDays ? maxDays : days); + }; + + const handleOnSubmit = async () => { + try { + const positionsByKey = positions?.reduce((acc, p) => { + acc[p.pubkey.toString()] = p; + return acc; + }, {} as Record); + setIsSubmitting(true); + + if (positionsByKey) { + await onSubmit({ + positions: Array.from(selectedPositions).map( + (p) => positionsByKey[p] + ), + recipient: new PublicKey(recipient), + expirationTime: new BN( + Math.min(expirationTime, maxDate.valueOf() / 1000) + ), + }); + setSelectedPositions(new Set([])); + setOpen(false); + } + } catch (e: any) { + setIsSubmitting(false); + toast(e.message || "Unable to assign proxy"); + } + }; + const handleOpenChange = () => { + setIsSubmitting(false); + setOpen(!open); + }; + + const selectedAll = unproxiedPositions.length === selectedPositions.size; + + return ( + + {children} + +
+
+

+ Assign Proxy +

+
+ Enter the amount of voting power and cycle period you’d like to + assign the selected voter +
+
+ + + setNetwork(network as "hnt" | "mobile" | "iot") + } + /> + + + + {loading ? ( + <> +
+
Fetching Positions available to Proxy
+
+
+ +
+ + ) : ( +
+
+
+
Assign Positions
+ +
+ +
+ {unproxiedPositions?.map((position) => { + return ( + { + setSelectedPositions((sel) => { + const key = position.pubkey.toBase58(); + const newS = new Set(sel); + if (sel.has(key)) { + newS.delete(key); + return newS; + } else { + newS.add(key); + return newS; + } + }); + }} + /> + ); + })} +
+
+ +
+ Your assigned proxy will expire by Aug 1 of each year by + default, however you may select any date prior to this epoch + date. +
+
+ )} +
+ + +
+
+
+
+ ); +}; + +export const PositionItem = ({ + position, + isSelected, + onClick, +}: { + position: PositionWithMeta; + isSelected: boolean; + onClick: () => void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/src/components/CreatePositionModal.tsx b/src/components/CreatePositionModal.tsx index 5357f24..6c05be8 100644 --- a/src/components/CreatePositionModal.tsx +++ b/src/components/CreatePositionModal.tsx @@ -1,18 +1,20 @@ "use client"; -import { daysToSecs, getMinDurationFmt, onInstructions } from "@/lib/utils"; +import { daysToSecs, onInstructions } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; -import { useAnchorProvider, useOwnedAmount } from "@helium/helium-react-hooks"; +import { useAnchorProvider, useMint, useOwnedAmount } from "@helium/helium-react-hooks"; import { HNT_MINT, toBN, toNumber } from "@helium/spl-utils"; import { + Position, + PositionWithMeta, calcLockupMultiplier, - useCreatePosition, + useCreatePosition } from "@helium/voter-stake-registry-hooks"; +import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import { useWallet } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import { Loader2 } from "lucide-react"; -import Image from "next/image"; import React, { FC, useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -24,7 +26,7 @@ import { StepIndicator } from "./StepIndicator"; import { SubDaoSelection } from "./SubDaoSelection"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; -import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; +import { PositionPreview } from "./PositionPreview"; export const CreatePositionModal: FC> = ({ children, @@ -74,6 +76,26 @@ export const CreatePositionModal: FC> = ({ const handleGoBack = () => { setStep(step - 1); }; + const { info: mintAcc } = useMint(mint); + + const draftPosition: Partial | undefined = useMemo( + () => formValues && ({ + lockup: { + startTs: new BN(new Date().getTime() / 1000), + endTs: new BN( + new Date().setDate( + new Date().getDate() + formValues.lockupPeriodInDays + ) / 1000 + ), + kind: formValues!.lockupKind == LockupKind.cliff ? { cliff: {} } as any : { decay: {} } as any, + }, + amountDepositedNative: toBN(formValues!.amount, mintAcc?.decimals || 6), + delegatedSubDao: selectedSubDaoPk, + // @ts-ignore + registrar: registrar?.pubkey, + }), + [formValues, mintAcc, selectedSubDaoPk, registrar] + ); const handleOpenChange = () => { setIsSubmitting(false); @@ -222,51 +244,7 @@ export const CreatePositionModal: FC> = ({ )} {step === steps && ( <> -
-
- {`${network} -
-
-
- - {formValues!.amount} {network.toUpperCase()} - - for - - {getMinDurationFmt( - new BN(Date.now() / 1000), - new BN( - new Date().setDate( - new Date().getDate() + formValues!.lockupPeriodInDays - ) / 1000 - ) - )} - - - {formValues!.lockupKind === LockupKind.cliff - ? "decaying starting today" - : "decaying delayed"} - -
- {selectedSubDao && ( -
- and delegated to -
- {selectedSubDao.dntMetadata.json?.name} -
- {selectedSubDao.dntMetadata.symbol} -
- )} -
-
+ { draftPosition && }
+
+ )} +
+ ); +}; diff --git a/src/components/NetworkSelect.tsx b/src/components/NetworkSelect.tsx new file mode 100644 index 0000000..4cc8401 --- /dev/null +++ b/src/components/NetworkSelect.tsx @@ -0,0 +1,46 @@ +"use client" + +import React from "react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { useNetwork } from "@/hooks/useNetwork"; +import { useGovernance } from "@/providers/GovernanceProvider"; + +export const NetworkSelect: React.FC<{ + network?: string + onNetworkChange: (network: string) => void +}> = ({ network, onNetworkChange }) => { + const { network: networkDefault } = useGovernance(); + const networkValue = network ?? networkDefault + + return ( + + ); +}; diff --git a/src/components/NetworkTabs.tsx b/src/components/NetworkTabs.tsx index e73a7bc..d62d1f8 100644 --- a/src/components/NetworkTabs.tsx +++ b/src/components/NetworkTabs.tsx @@ -25,7 +25,11 @@ const NetworkTab: FC<{

{name}

-
+
); diff --git a/src/components/Pill.tsx b/src/components/Pill.tsx index 1d7ad61..72e41e6 100644 --- a/src/components/Pill.tsx +++ b/src/components/Pill.tsx @@ -13,6 +13,8 @@ const pillVariants = cva( "bg-warning text-warning-foreground border-warning-foreground/20", info: "bg-info text-info-foreground border-info-foreground/20", purple: "bg-purple text-purple-foreground border-purple-foreground/20", + pink: "bg-pink text-pink-foreground border-pink-foreground/20", + blue: "bg-blue-500 text-white border-blue-500/20", }, }, defaultVariants: { diff --git a/src/components/PositionCard.tsx b/src/components/PositionCard.tsx index ff141a7..13a45fe 100644 --- a/src/components/PositionCard.tsx +++ b/src/components/PositionCard.tsx @@ -1,13 +1,17 @@ "use client"; import { + ellipsisMiddle, getMinDurationFmt, getTimeLeftFromNowFmt, humanReadable, } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; import { useSolanaUnixNow } from "@helium/helium-react-hooks"; -import { PositionWithMeta } from "@helium/voter-stake-registry-hooks"; +import { + PositionWithMeta, + useKnownProxy, +} from "@helium/voter-stake-registry-hooks"; import BN from "bn.js"; import classNames from "classnames"; import Image from "next/image"; @@ -16,48 +20,43 @@ import { usePathname } from "next/navigation"; import React, { FC, useMemo } from "react"; import { Pill } from "./Pill"; import { Button } from "./ui/button"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "./ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Skeleton } from "./ui/skeleton"; +import { PublicKey } from "@solana/web3.js"; export const PositionCardSkeleton: FC<{ compact?: boolean }> = () => { const { network } = useGovernance(); const canDelegate = network === "hnt"; return ( - - + +
- -
+ +
-
+
{canDelegate && (
-
+
)} - + ); }; @@ -67,7 +66,14 @@ export const PositionCard: FC<{ className?: string; compact?: boolean; onClick?: () => void; -}> = ({ position, className = "", compact = false, onClick }) => { + canDelegate?: boolean; +}> = ({ + canDelegate: canDelegateIn = true, + position, + className = "", + compact = false, + onClick, +}) => { const path = usePathname(); const { lockup, hasGenesisMultiplier } = position; const { loading: loadingGov, network, mintAcc, subDaos } = useGovernance(); @@ -78,7 +84,8 @@ export const PositionCard: FC<{ const elapsedTime = new BN(unixNow).sub(lockup.startTs); const totalTime = lockup.endTs.sub(lockup.startTs); const decayedPercentage = elapsedTime.muln(100).div(totalTime); - const canDelegate = network === "hnt"; + const canDelegate = canDelegateIn && network === "hnt"; + const { knownProxy } = useKnownProxy(position?.proxy?.nextVoter); const lockedTokens = mintAcc && humanReadable(position.amountDepositedNative, mintAcc.decimals); @@ -160,92 +167,148 @@ export const PositionCard: FC<{ ); } + const renderTags = () => ( +
+ {hasGenesisMultiplier && ( + + Landrush + + )} + {isDecayed && ( + + 100%{" "} +  Decayed + + )} + {isConstant && Paused} + {!isConstant && !isDecayed && ( + + {decayedPercentage.toString()}% +  Decayed + + )} +
+ ); + return ( - - - -
-
+ + + +
+
{`%{network}`}
-
- {isDecayed && 100% Decayed} - {isConstant && Paused} - {!isConstant && !isDecayed && ( - - {decayedPercentage.toString()}% Decayed - - )} -
-
- -
- {lockedTokens} for - + + {`${lockedTokens} ${network.toUpperCase()}`}{" "} + {getMinDurationFmt( position.lockup.startTs, position.lockup.endTs )} -
- {hasGenesisMultiplier && ( - - Landrush - - )} -
+ +
+
{renderTags()}
- -
-

VOTING POWER

-
-

{votingPower}

- {hasGenesisMultiplier && ( - - x3 - - )} + +
+
+

VOTING POWER

+
+

{votingPower}

+ {hasGenesisMultiplier && ( + + x3 + + )} +
+
+
+

TIME LEFT

+

+ {isConstant + ? getMinDurationFmt( + position.lockup.startTs, + position.lockup.endTs + ) + : getTimeLeftFromNowFmt(position.lockup.endTs)} +

-
-

TIME LEFT

-

- {isConstant - ? getMinDurationFmt( - position.lockup.startTs, - position.lockup.endTs - ) - : getTimeLeftFromNowFmt(position.lockup.endTs)} -

-
-
- {canDelegate && ( - -

DELEGATED TO

- {!isDecayed && delegatedSubDaoMetadata ? ( -
-
- delegated-subdao -
-

{delegatedSubDaoMetadata.symbol}

-
- ) : !isDecayed ? ( +
+
+

PROXIED TO

+ {position.proxy && + !position.proxy.nextVoter.equals(PublicKey.default) ? ( - + + {knownProxy?.name || + ellipsisMiddle(position.proxy.nextVoter.toBase58())} + ) : ( -

UNDELEGATED

+ + + )} - - )} +
+ {canDelegate && ( +
+

DELEGATED TO

+ {!isDecayed && delegatedSubDaoMetadata ? ( +
+
+ delegated-subdao +
+

{delegatedSubDaoMetadata.symbol}

+
+ ) : !isDecayed ? ( + + + + ) : ( +

Undelegated

+ )} +
+ )} +
+ {renderTags()} +
+ ); diff --git a/src/components/PositionManager/PositionActionBoundary.tsx b/src/components/PositionManager/PositionActionBoundary.tsx index 9eb842d..2b8aa83 100644 --- a/src/components/PositionManager/PositionActionBoundary.tsx +++ b/src/components/PositionManager/PositionActionBoundary.tsx @@ -28,7 +28,7 @@ export const PositionActionBoundary: FC< const { hasRewards, isDelegated, numActiveVotes } = position; const hasVotes = numActiveVotes > 0; const hasBlockers = hasRewards || isDelegated || hasVotes; - const tryingToUnblock = !hasRewards && action === "delegate"; + const canDoWhileBlocked = !hasRewards && action === "delegate" || action == "proxy"; if (!action) { return children; @@ -36,7 +36,7 @@ export const PositionActionBoundary: FC< return (
- {hasBlockers && !tryingToUnblock && ( + {hasBlockers && !canDoWhileBlocked && (
+
@@ -133,7 +133,7 @@ export const PositionCallout: FC<{
)}
-
+
VOTING @@ -168,39 +168,43 @@ export const PositionCallout: FC<{

- {!isDecayed ? ( -
- - {canDelegate && ( - + {!position.isProxiedToMe && ( + <> + {!isDecayed ? ( +
+ + {canDelegate && ( + + )} +
+ ) : ( +
+ +
)} -
- ) : ( -
- -
+ )}
); diff --git a/src/components/PositionManager/PositionManager.tsx b/src/components/PositionManager/PositionManager.tsx index 94de9ce..1b8c8e2 100644 --- a/src/components/PositionManager/PositionManager.tsx +++ b/src/components/PositionManager/PositionManager.tsx @@ -6,10 +6,12 @@ import { useAnchorProvider, useSolanaUnixNow, } from "@helium/helium-react-hooks"; +import { RiUserSharedFill } from "react-icons/ri"; import { toNumber } from "@helium/spl-utils"; import { PositionWithMeta, SubDaoWithMeta, + useAssignProxies, useClaimPositionRewards, useClosePosition, useDelegatePosition, @@ -18,6 +20,7 @@ import { useRelinquishPositionVotes, useSplitPosition, useTransferPosition, + useUnassignProxies, } from "@helium/voter-stake-registry-hooks"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; import BN from "bn.js"; @@ -44,6 +47,8 @@ import { PositionCallout } from "./PositionCallout"; import { ReclaimPositionPrompt } from "./ReclaimPositionPrompt"; import { SplitPositionPrompt } from "./SplitPositionPrompt"; import { UpdatePositionDelegationPrompt } from "./UpdatePositionDelegationPrompt"; +import { ProxyPositionPrompt } from "./ProxyPositionPrompt"; +import { PublicKey } from "@solana/web3.js"; export type PositionAction = | "flip" @@ -51,7 +56,8 @@ export type PositionAction = | "extend" | "split" | "merge" - | "reclaim"; + | "reclaim" + | "proxy"; export interface PositionManagerProps { initAction?: PositionAction; @@ -102,6 +108,7 @@ export const PositionManager: FC = ({ } = useGovernance(); const router = useRouter(); const { lockup } = position; + const isConstant = Object.keys(lockup.kind)[0] === "constant"; const unixNow = useSolanaUnixNow() || Date.now() / 1000; const isDecayed = !isConstant && lockup.endTs.lte(new BN(unixNow)); @@ -111,7 +118,6 @@ export const PositionManager: FC = ({ return []; } - const { lockup } = position; const lockupKind = Object.keys(lockup.kind)[0]; const positionLockupPeriodInDays = secsToDays( lockupKind === "constant" @@ -138,6 +144,7 @@ export const PositionManager: FC = ({ lockupPeriodInDays >= positionLockupPeriodInDays ); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [position, unixNow, positions]); const maxActionableAmount = mintAcc @@ -149,6 +156,11 @@ export const PositionManager: FC = ({ setAction(undefined); }, [refetchState, setAction]); + const { isPending: isAssigningProxy, mutateAsync: assignProxies } = + useAssignProxies(); + const { isPending: isRevokingProxy, mutateAsync: unassignProxies } = + useUnassignProxies(); + const isUpdatingProxy = isAssigningProxy || isRevokingProxy; const { loading: isFlipping, flipPositionLockupKind } = useFlipPositionLockupKind(); const { loading: isClaiming, claimPositionRewards } = @@ -161,6 +173,44 @@ export const PositionManager: FC = ({ const { loading: isRelinquishing, relinquishPositionVotes } = useRelinquishPositionVotes(); + const handleUpdateProxy = async ({ + proxy, + expirationTime, + isRevoke, + }: { + proxy?: string; + expirationTime?: number; + isRevoke?: boolean; + }) => { + try { + if (isRevoke) { + await unassignProxies({ + positions: [position], + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true + }), + }); + } else { + await assignProxies({ + positions: [position], + recipient: new PublicKey(proxy || ""), + expirationTime: new BN(expirationTime || 0), + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true + }), + }); + } + toast(`Proxy ${isRevoke ? "revoked" : "assigned"}`); + } catch (e: any) { + if (!(e instanceof WalletSignTransactionError)) { + toast( + e.message || + `${isRevoke ? "Revoke" : "Assign"} failed, please try again` + ); + } + } + }; + const handleRelinquishPositionVotes = async () => { try { await relinquishPositionVotes({ @@ -333,49 +383,58 @@ export const PositionManager: FC = ({ handleClaimRewards={handleClaimPositionRewards} />
-
-
- - Position Actions - -
-
- {canDelegate && ( + {!position.isProxiedToMe && ( +
+
+ + Position Actions + +
+
setAction("delegate")} + active={action === "proxy"} + Icon={() => } + onClick={() => setAction("proxy")} > - Update Delegation + Update Proxy - )} - ( - + {canDelegate && ( + setAction("delegate")} + > + Update Delegation + )} - onClick={() => setAction("extend")} - > - Extend Position - - setAction("split")} - > - Split Position - - setAction("merge")} - > - Merge Position - + ( + + )} + onClick={() => setAction("extend")} + > + Extend Position + + setAction("split")} + > + Split Position + + setAction("merge")} + > + Merge Position + +
+
- -
+ )}
= ({
)} + {action === "proxy" && ( + setAction(undefined)} + onConfirm={handleUpdateProxy} + /> + )} {action === "flip" && ( void; + onConfirm: ({ + proxy, + expirationTime, + isRevoke, + }: { + proxy?: string; + expirationTime?: number; + isRevoke?: boolean; + }) => Promise; +}> = ({ position, isSubmitting, onCancel, onConfirm }) => { + const isProxied = + position.proxy?.nextVoter && + !position.proxy?.nextVoter.equals(PublicKey.default); + const { knownProxy } = useKnownProxy(position.proxy?.nextVoter); + const [proxy, setProxy] = useState( + position.proxy?.nextVoter.equals(PublicKey.default) + ? "" + : position.proxy?.nextVoter.toBase58() || "" + ); + const { network } = useGovernance(); + + const today = new Date(); + const augustFirst = Date.UTC( + today.getMonth() >= 7 ? today.getFullYear() + 1 : today.getFullYear(), + 7, + 1 + ); + const maxDate = augustFirst - 1000; + const maxDays = Math.floor( + (maxDate - today.getTime()) / (1000 * 60 * 60 * 24) + ); + const [selectedDays, setSelectedDays] = useState(maxDays); + const expirationTime = useMemo( + () => + selectedDays === maxDays + ? maxDate.valueOf() / 1000 + : new Date().valueOf() / 1000 + selectedDays * (24 * 60 * 60), + [selectedDays, maxDays, maxDate] + ); + + const handleSubmit = async () => { + await onConfirm({ proxy, expirationTime, isRevoke: isProxied }); + }; + + return ( +
+
+ +
+
+

Update Proxy

+ {isProxied ? ( +

+ Your position is currently proxied to{" "} + {knownProxy?.name || position?.proxy?.nextVoter.toBase58()} +

+ ) : ( + <> +

+ Assign proxy to a trusted voter if you don’t want to vote. + You can override any active votes anytime - your vote takes + precedence over a proxy. +

+
+ + + +
+
+
+ + OR + +
+
+ + )} +
+ {isProxied ? ( +
+
+ + +
+
+

+ A network fee will be required +

+
+
+ ) : ( + <> + + +
+
+ + +
+
+

+ A network fee will be required +

+
+
+ + )} +
+ ); +}; diff --git a/src/components/PositionPreview.tsx b/src/components/PositionPreview.tsx new file mode 100644 index 0000000..78cd3b9 --- /dev/null +++ b/src/components/PositionPreview.tsx @@ -0,0 +1,71 @@ +import { networksToMint } from "@/lib/constants"; +import { getMinDurationFmt, humanReadable } from "@/lib/utils"; +import { useGovernance } from "@/providers/GovernanceProvider"; +import { useMint } from "@helium/helium-react-hooks"; +import { PositionWithMeta, useRegistrar } from "@helium/voter-stake-registry-hooks"; +import BN from "bn.js"; +import Image from "next/image"; +import { useMemo } from "react"; + +export const PositionPreview: React.FC<{ + position: Partial; +}> = ({ position }) => { + const { info: registrar } = useRegistrar(position.registrar); + const votingMint = registrar?.votingMints[0].mint; + const network = + Object.entries(networksToMint).find( + ([_, mint]) => votingMint && mint.equals(votingMint) + )?.[0] || "hnt"; + const { info: mint } = useMint(votingMint) + const amount = humanReadable(position.amountDepositedNative, mint?.decimals); + const { subDaos } = useGovernance() + const subDao = useMemo( + () => + subDaos?.find( + (s) => + position.delegatedSubDao && s.pubkey.equals(position.delegatedSubDao) + ), + [subDaos, position.delegatedSubDao] + ); + + return ( +
+
+ {`${network} +
+
+
+ + {amount} {network.toUpperCase()} + + for + + {position.lockup?.endTs + ? getMinDurationFmt( + new BN(Date.now() / 1000), + position.lockup?.endTs + ) + : null} + + + {position.lockup?.kind?.cliff ? "decaying" : "decaying delayed"} + +
+ {subDao && ( +
+ and delegated to +
+ {subDao.dntMetadata.json?.name} +
+ {subDao.dntMetadata.symbol} +
+ )} +
+
+ ); +}; + diff --git a/src/components/Positions.tsx b/src/components/Positions.tsx index d627025..352e901 100644 --- a/src/components/Positions.tsx +++ b/src/components/Positions.tsx @@ -14,7 +14,7 @@ import { toast } from "sonner"; import { Skeleton } from "./ui/skeleton"; import { CreatePositionButton } from "./CreatePositionButton"; import { onInstructions } from "@/lib/utils"; -import { useAnchorProvider } from "@helium/helium-react-hooks"; +import { useAnchorProvider, useSolanaUnixNow } from "@helium/helium-react-hooks"; import { ContentSection } from "./ContentSection"; import { WalletSignTransactionError } from "@solana/wallet-adapter-base"; @@ -56,26 +56,34 @@ export const Positions: FC = () => { [positions, loadingGov] ); + const proxiedPositions = useMemo( + () => sortedPositions?.filter((p) => p.isProxiedToMe), + [sortedPositions] + ); + const unProxiedPositions = useMemo( + () => sortedPositions?.filter((p) => !p.isProxiedToMe), + [sortedPositions] + ); const decayedPositions = useMemo( () => - sortedPositions + unProxiedPositions ?.filter((p) => p.lockup.kind.cliff) .filter((p) => p.lockup.endTs.lte(new BN(Date.now() / 1000))), - [sortedPositions] + [unProxiedPositions] ); const notDecayedPositions = useMemo( () => - sortedPositions?.filter( + unProxiedPositions?.filter( (p) => p.lockup.kind.constant || p.lockup.endTs.gt(new BN(Date.now() / 1000)) ), - [sortedPositions] + [unProxiedPositions] ); const positionsWithRewards = useMemo( - () => positions?.filter((p) => p.hasRewards), - [positions] + () => unProxiedPositions?.filter((p) => p.hasRewards), + [unProxiedPositions] ); const { loading: claimingAllRewards, claimAllPositionsRewards } = @@ -116,7 +124,7 @@ export const Positions: FC = () => {
- + {[...Array(5)].map((_, i) => ( ))} @@ -132,24 +140,27 @@ export const Positions: FC = () => {

All Positions

- {network === "hnt" && ( - - )} +
+ + {network === "hnt" && ( + + )} +
{!notDecayedPositions?.length && !decayedPositions?.length && ( - +

No positions

@@ -172,7 +183,7 @@ export const Positions: FC = () => { Reclaim All */} - + {decayedPositions.map((position) => ( {
*/} - + {notDecayedPositions.map((position) => ( { )} + {proxiedPositions && proxiedPositions.length > 0 && ( + + + Proxied to Me + + + {proxiedPositions.map((position) => ( + + ))} + + + )}
); diff --git a/src/components/Proposal.tsx b/src/components/Proposal.tsx index 55d47f8..0d922d2 100644 --- a/src/components/Proposal.tsx +++ b/src/components/Proposal.tsx @@ -1,15 +1,8 @@ "use client"; -import { ProposalV0 } from "@/lib/types"; -import { getDerivedProposalState, humanReadable } from "@/lib/utils"; +import { useProposalInfo } from "@/hooks/useProposalInfo"; +import { humanReadable } from "@/lib/utils"; import { useGovernance } from "@/providers/GovernanceProvider"; -import { useMint } from "@helium/helium-react-hooks"; -import { - useProposal, - useProposalConfig, - useResolutionSettings, -} from "@helium/modular-governance-hooks"; -import { useRegistrar, useVote } from "@helium/voter-stake-registry-hooks"; import { Separator } from "@radix-ui/react-separator"; import { useWallet } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; @@ -18,20 +11,24 @@ import classNames from "classnames"; import { format } from "date-fns"; import Image from "next/image"; import Link from "next/link"; -import React, { FC, useMemo, useRef, useState } from "react"; +import { FC, useMemo } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { FaArrowLeft, - FaChevronDown, FaCopy, FaDiscord, FaGithub, FaXTwitter, } from "react-icons/fa6"; -import Markdown from "react-markdown"; +import { + IoStopwatchOutline, + IoFlagOutline, + IoFlashOutline, +} from "react-icons/io5"; import { toast } from "sonner"; import { ContentSection } from "./ContentSection"; import { CountdownTimer } from "./CountdownTimer"; +import { Markdown } from "./Markdown"; import { VoteBreakdown } from "./VoteBreakdown"; import { VoteOptions } from "./VoteOptions"; import { VoteResults } from "./VoteResults"; @@ -45,51 +42,68 @@ const MARKDOWN_MAX = 540; export const ProposalSkeleton = () => ( <> - - - - - -
-
- {[...Array(3)].map((_, i) => ( - - ))} -
-
- - {[...Array(3)].map((_, i) => ( -
-
- -
- - - - + + +
+
+ + + +
+
+ {[...Array(3)].map((_, i) => ( + + ))}
-
- ))} - - + + {[...Array(3)].map((_, i) => ( +
+
+ +
+ + + + +
+
+
+ ))} + + +
+
+ + + + + + + + + + +
+
+
); const ProposalHipBlurb: FC<{ network: string }> = ({ network }) => ( -
-
+
+
{`ve${network}`}
-

+

This HIP affects the {network.toUpperCase()} network which requires ve {network.toUpperCase()} positions for vote participation.

- + Manage Voting Power
@@ -102,30 +116,45 @@ export const ProposalBreakdown: FC<{ totalVotes?: BN; decimals?: number; }> = ({ timeExpired, endTs, isCancelled, totalVotes, decimals }) => ( -
-
-

{timeExpired ? "DATE OCCURRED" : "DEADLINE"}

-

- {format(new Date((endTs?.toNumber() || 0) * 1000), "PPp")} -

+
+
+ +
+

+ {timeExpired ? "DATE OCCURRED" : "DEADLINE"} +

+

+ {format(new Date((endTs?.toNumber() || 0) * 1000), "PPp")} +

+
{!timeExpired && !isCancelled ? ( -
+ <> -

EST. TIME REMAINING

-
- +
+ +
+

EST. TIME REMAINING

+
+ +
+
-
+ ) : null} {totalVotes && ( -
+ <> -

TOTAL veTOKENS

-

- {humanReadable(totalVotes, decimals)} -

-
+
+ +
+

TOTAL veTOKENS

+

+ {humanReadable(totalVotes, decimals)} +

+
+
+ )}
); @@ -138,16 +167,19 @@ export const ProposalSocial: FC<{ isCancelled?: boolean; }> = ({ network, proposalKey, githubUrl, twitterUrl, isCancelled }) => { return ( -
+
- {githubUrl && ( @@ -155,11 +187,14 @@ export const ProposalSocial: FC<{ href={githubUrl} rel="noopener noreferrer" target="_blank" - className="flex flex-row" + className="flex flex-1" > - )} @@ -168,23 +203,31 @@ export const ProposalSocial: FC<{ href={twitterUrl.toString()} rel="noopener noreferrer" target="_blank" - className="flex flex-row" + className="flex flex-1" > - )} - toast("Link copied to clipboard")} - > - - +
+ toast("Link copied to clipboard")} + > + + +
); }; @@ -195,73 +238,22 @@ export const Proposal: FC<{ proposalKey: string; }> = ({ name: initName, content, proposalKey }) => { const { connected, connecting } = useWallet(); - const markdownRef = useRef(null); - const [markdownExpanded, setMarkdownExpanded] = useState(false); - const { loading: loadingGov, amountLocked, network } = useGovernance(); + const { network } = useGovernance(); const pKey = useMemo(() => new PublicKey(proposalKey), [proposalKey]); - const { loading: loadingProposal, info: proposal } = useProposal(pKey); - const { loading: loadingVote, voteWeights } = useVote(pKey); - const name = proposal?.name || initName; - const { info: proposalConfig } = useProposalConfig(proposal?.proposalConfig); - const { info: registrar } = useRegistrar(proposalConfig?.voteController); - const decimals = useMint(registrar?.votingMints[0].mint)?.info?.decimals; - const { info: resolution } = useResolutionSettings( - proposalConfig?.stateController - ); - - const votingResults = useMemo(() => { - const totalVotes: BN = [...(proposal?.choices || [])].reduce( - (acc, { weight }) => weight.add(acc) as BN, - new BN(0) - ); - - const results = proposal?.choices.map((r, index) => ({ - ...r, - index, - percent: totalVotes?.isZero() - ? 100 / proposal?.choices.length - : // Calculate with 4 decimals of precision - r.weight.mul(new BN(10000)).div(totalVotes).toNumber() * - (100 / 10000), - })); - - return { results, totalVotes }; - }, [proposal]); - - const markdownHeight = useMemo( - () => markdownRef.current?.clientHeight || 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - [markdownRef.current] - ); + const { + voted, + completed, + isCancelled, + noVotingPower, + timeExpired, + isLoading, + votingResults, + proposal, + decimals, + endTs, + } = useProposalInfo(pKey); - const derivedState = useMemo( - () => getDerivedProposalState(proposal as ProposalV0), - [proposal] - ); - - const endTs = - resolution && - (proposal?.state.resolved - ? proposal?.state.resolved.endTs - : proposal?.state.voting?.startTs.add( - resolution.settings.nodes.find( - (node) => typeof node.offsetFromStartTs !== "undefined" - )?.offsetFromStartTs?.offset ?? new BN(0) - )); - - const isLoading = useMemo( - () => connecting || loadingGov || loadingProposal || !proposal, - [connecting, loadingGov, loadingProposal, proposal] - ); - const timeExpired = endTs && endTs.toNumber() <= Date.now().valueOf() / 1000; - const noVotingPower = !isLoading && (!amountLocked || amountLocked.isZero()); - const isActive = derivedState === "active"; - const isCancelled = derivedState === "cancelled"; - const isFailed = derivedState === "failed"; - const completed = - timeExpired || (timeExpired && isActive) || isCancelled || isFailed; - - const voted = !loadingVote && voteWeights?.some((n) => n.gt(new BN(0))); + const name = proposal?.name || initName; const twitterUrl = useMemo(() => { if (endTs) { @@ -282,23 +274,13 @@ export const Proposal: FC<{ } }, [proposalKey, endTs, network, completed, name]); - const rewriteLinks = () => { - const visit = require("unist-util-visit"); - - return function transformer(tree: any) { - visit.visit(tree, "link", (node: any) => { - node.data = { - ...node.data, - hProperties: { - ...(node.data || {}).hProperties, - target: "_blank", - }, - }; - }); - }; - }; + if (connecting || isLoading) + return ( +
+ +
+ ); - if (isLoading) return ; return ( <>
- - - - - - Back to Votes - -
- {proposal?.tags - .filter((tag) => tag !== "tags") - .map((tag, i) => ( - 0, - } - )} +
+ +
+
+ + + - {tag} - - ))} -
-

{name}

- - -
-
- {!completed && !connected && ( -
- Please connect a wallet to participate. - -
- )} - {connected && noVotingPower && !completed && ( -
-

- No voting power detected. This HIP affects the{" "} - {network.toUpperCase()} network which requires ve - {network.toUpperCase()} positions for vote participation. -

-
- - Manage Voting Power - -
+ + Back to Votes + +
+ {proposal?.tags + .filter((tag) => tag !== "tags") + .map((tag, i) => ( + 0, + } + )} + > + {tag} + + ))}
- )} - {connected && !noVotingPower && !completed && ( - - )} - {(completed || (connected && !noVotingPower && voted)) && - votingResults?.totalVotes.gt(new BN(0)) && ( -
- +

{name}

+ + +
+
+ {!completed && !connected && ( +
+ Please connect a wallet to participate. + +
+ )} + {connected && noVotingPower && !completed && ( +
+

+ No voting power detected. This HIP affects the{" "} + {network.toUpperCase()} network which requires ve + {network.toUpperCase()} positions for vote + participation. +

+
+ + Manage Voting Power + +
+
+ )} + {connected && + !noVotingPower && + !completed && + proposal && + votingResults && ( + + )} + {(completed || (connected && !noVotingPower && voted)) && + votingResults && + votingResults?.totalVotes.gt(new BN(0)) && ( +
+ +
+ )}
- )} -
- - - -
-
-
- - {content.replace(name, "")} -
- - {completed && - markdownHeight > MARKDOWN_MAX && - !markdownExpanded && ( -
- -
- )} -
-
-
+ + +
+ {!isCancelled && completed && ( + + + + )} + + + {content.replace(name, "")} + +
- {!isCancelled && completed && ( -
- -
- )} -
- - +
+ + + +
+
+ +
); }; diff --git a/src/components/Proposals.tsx b/src/components/Proposals.tsx index 905e59a..7c04fc1 100644 --- a/src/components/Proposals.tsx +++ b/src/components/Proposals.tsx @@ -22,7 +22,7 @@ export const Proposals: FC> = ({ const { loading, accounts: proposalsWithDups } = useOrganizationProposals(organization); - const undupedProposals = useMemo(() => { + const dedupedProposals = useMemo(() => { const seen = new Set(); return (proposalsWithDups || []) .filter((p) => { @@ -35,20 +35,20 @@ export const Proposals: FC> = ({ const activeProposals = useMemo( () => - undupedProposals.filter( + dedupedProposals.filter( (proposal) => getDerivedProposalState(proposal.info as ProposalV0) === "active" ), - [undupedProposals] + [dedupedProposals] ); const inactiveProposals = useMemo( () => - undupedProposals.filter( + dedupedProposals.filter( (proposal) => getDerivedProposalState(proposal.info as ProposalV0) !== "active" ), - [undupedProposals] + [dedupedProposals] ); const isLoading = useMemo(() => loading || loadingGov, [loading, loadingGov]); diff --git a/src/components/Proxies.tsx b/src/components/Proxies.tsx new file mode 100644 index 0000000..b502ed8 --- /dev/null +++ b/src/components/Proxies.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { ellipsisMiddle, humanReadable } from "@/lib/utils"; +import { useGovernance } from "@/providers/GovernanceProvider"; +import { useMint } from "@helium/helium-react-hooks"; +import { proxiesQuery } from "@helium/voter-stake-registry-hooks"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import BN from "bn.js"; +import { Loader2 } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; +import { FaMagnifyingGlass, FaX } from "react-icons/fa6"; +import { IoWarningOutline } from "react-icons/io5"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { ContentSection } from "./ContentSection"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { Card } from "./ui/card"; +import { Input } from "./ui/input"; +import { Skeleton } from "./ui/skeleton"; +import { useDebounce } from "@uidotdev/usehooks"; +import { EnhancedProxy } from "@helium/voter-stake-registry-sdk"; + +const DECENTRALIZATION_RISK_PERCENT = 10; + +function CardDetail({ title, value }: { title: string; value: string }) { + return ( +
+ + {title} + + {value} +
+ ); +} + +const ProxyCardSkeleton: React.FC = () => { + return ( + +
+ + +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +}; + +export function Proxies() { + const { voteService, mint } = useGovernance(); + const { info: mintAcc } = useMint(mint); + const decimals = mintAcc?.decimals; + const path = usePathname(); + const [proxySearch, setProxySearch] = useState(""); + const searchDebounced = useDebounce(proxySearch, 300); + const { + data: voters, + fetchNextPage, + hasNextPage, + isLoading, + isFetchingNextPage, + } = useInfiniteQuery( + proxiesQuery({ + search: searchDebounced, + amountPerPage: 100, + voteService, + }) + ); + const proxies = useMemo(() => voters?.pages.flat() || [], [voters]); + + const renderBelowIndex = useMemo( + () => + proxies.findIndex( + (proxy) => Number(proxy.percent) < DECENTRALIZATION_RISK_PERCENT + ), + [proxies] + ); + + return ( + +
+
+

Browse Proxies

+
+ + {proxySearch ? ( + setProxySearch("")} + /> + ) : null} + setProxySearch(e.target.value)} + placeholder="Search name or address..." + className="w-full appearance-none bg-secondary border-none pl-12 pr-10 shadow-none" + /> +
+
+ fetchNextPage()} + hasMore={hasNextPage} + loader={ +
+ +
+ } + endMessage={ +

+ {proxies.length} of {proxies.length} voters loaded. You've + reached the end of the list +

+ } + > +
+ {proxies.map((proxy, index) => ( + <> + + +
+ + + {proxy.name} + +
+

{proxy.name}

+ + {ellipsisMiddle(proxy.wallet)} + +
+
+ +
+ + + +
+
+ + {renderBelowIndex > 0 && index === renderBelowIndex ? ( +
+ + + Assigning proxy to top voters may threaten the + decentralization of the network. Consider assigning proxy + to voters below this line. + +
+ ) : null} + + ))} + {(isLoading || isFetchingNextPage) && } +
+
+
+
+ ); +} diff --git a/src/components/ProxyButton.tsx b/src/components/ProxyButton.tsx new file mode 100644 index 0000000..28699c6 --- /dev/null +++ b/src/components/ProxyButton.tsx @@ -0,0 +1,50 @@ +import React, { useMemo } from "react"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { PublicKey } from "@solana/web3.js"; +import { Button } from "./ui/button"; +import { Loader2 } from "lucide-react"; +import { useGovernance } from "@/providers/GovernanceProvider"; +import { RiUserSharedFill } from "react-icons/ri"; +import { cn } from "@/lib/utils"; + +export const ProxyButton = React.forwardRef< + HTMLButtonElement, + { + className?: string; + onClick?: () => void; + isLoading?: boolean; + } +>(({ className = "", onClick, isLoading = false }, ref) => { + const { connected } = useWallet(); + const { loading, positions } = useGovernance(); + + const unproxiedPositions = useMemo( + () => + positions?.filter( + (p) => !p.proxy || p.proxy.nextVoter.equals(PublicKey.default) + ), + [positions] + ); + + return ( + + ); +}); + +ProxyButton.displayName = "ProxyButton"; diff --git a/src/components/ProxyProfile.tsx b/src/components/ProxyProfile.tsx new file mode 100644 index 0000000..f756ab4 --- /dev/null +++ b/src/components/ProxyProfile.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { networksToMint } from "@/lib/constants"; +import { ellipsisMiddle, humanReadable, onInstructions } from "@/lib/utils"; +import { useGovernance } from "@/providers/GovernanceProvider"; +import { useAnchorProvider, useMint } from "@helium/helium-react-hooks"; +import { + proxyQuery, + useAssignProxies, + useProxiedTo, + useUnassignProxies, +} from "@helium/voter-stake-registry-hooks"; +import { VoteService, getRegistrarKey } from "@helium/voter-stake-registry-sdk"; +import { PublicKey } from "@solana/web3.js"; +import { useQuery } from "@tanstack/react-query"; +import BN from "bn.js"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; +import { useAsync } from "react-async-hook"; +import { FaArrowLeft } from "react-icons/fa6"; +import { AssignProxyModal } from "./AssignProxyModal"; +import { ContentSection } from "./ContentSection"; +import { Markdown } from "./Markdown"; +import { ProxyButton } from "./ProxyButton"; +import { RevokeProxyButton } from "./RevokeProxyButton"; +import { RevokeProxyModal } from "./RevokeProxyModal"; +import VoteHistory from "./VoteHistory"; +import { Card, CardContent, CardHeader } from "./ui/card"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; + +export function ProxyProfile({ wallet: walletRaw }: { wallet: string }) { + const wallet = useMemo(() => new PublicKey(walletRaw), [walletRaw]); + const { mint, network, voteService } = useGovernance(); + const { data: proxyRaw, error } = useQuery( + proxyQuery({ + wallet: useMemo(() => new PublicKey(wallet), [wallet]), + voteService, + }) + ); + // Due to hydration, should always be present + const proxy = proxyRaw!; + const detail = proxy.detail; + const image = proxy.image; + const { info: mintAcc } = useMint(mint); + const decimals = mintAcc?.decimals; + const { mutateAsync: assignProxies } = useAssignProxies(); + const { mutateAsync: unassignProxies } = useUnassignProxies(); + const { votingPower, positions } = useProxiedTo(wallet); + const { result: networks } = useAsync( + async (vs: VoteService | undefined) => { + if (vs) { + const registrars = await vs.getRegistrarsForProxy( + new PublicKey(proxy.wallet) + ); + if (registrars) { + return new Set( + Object.entries(networksToMint) + .filter(([_, mint]) => { + return registrars.includes(getRegistrarKey(mint).toBase58()); + }) + .map(([network]) => network) + ); + } + } + return new Set(); + }, + [voteService] + ); + + const path = usePathname(); + const currentPath = path.split("/")[1] || "hnt"; + function getNetworkPath(network: string) { + const [_firstSlash, _network, ...split] = path.split("/"); + return "/" + [network, ...split].join("/"); + } + + const infoCard = ( +
+
+
+ VOTING POWER +
+
+
+
+ TOTAL POWER +
+
+ {proxy.proxiedVeTokens && decimals + ? // Force 2 decimals + humanReadable( + new BN(proxy.proxiedVeTokens).div( + new BN(Math.pow(10, decimals - 2)) + ), + 2 + ) + : "0"} +
+
+
+
+ PERCENTAGE +
+
+ {Number(proxy.percent).toFixed(2)} +
+
+
+
+
+
+ PROXIES +
+
+
+
+ PROPOSALS VOTED +
+
+ {proxy.numProposalsVoted} +
+
+
+
+ NUM ASSIGNMENTS +
+
+ {proxy.numAssignments} +
+
+
+
+ {votingPower?.gt(new BN(0)) && ( + <> +
+
+
+ MY PROXIES +
+
+
+
+ POWER FROM ME +
+
+ {humanReadable( + votingPower.div(new BN(Math.pow(10, (decimals || 0) - 2))), + 2 + )} +
+
+
+
+ POSITIONS ASSIGNED +
+
+ {positions?.length} +
+
+
+
+ + )} +
+ ); + + const provider = useAnchorProvider() + + return ( + + + + + + Back to Proxy Voters + +
+
+ + + {proxy.name} + +
+

{proxy.name}

+ + {proxy.wallet && ellipsisMiddle(proxy.wallet)} + +
+
+
+
+
+ Current Rank + + #{proxy.rank} of {proxy.numProxies} + +
+
+ Last Voted + + {proxy.lastVotedAt + ? new Date(proxy.lastVotedAt).toLocaleDateString() + : "Never"} + +
+
+
+ + { + return assignProxies({ + ...args, + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true, + }), + }); + }} + wallet={wallet} + > + {}} + isLoading={false} + /> + + + unassignProxies({ + ...args, + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true, + }), + }) + } + wallet={wallet} + > + {}} + isLoading={false} + /> + + + +
+ + {detail || `${proxy.wallet}`} +
{infoCard}
+
+
+
+

Proposals

+ + {networks?.has("hnt") && ( + + + hnt Icon + HNT + + + )} + {networks?.has("mobile") && ( + + + moile Icon + MOBILE + + + )} + {networks?.has("iot") && ( + + + iot Icon + IOT + + + )} + +
+ +
+
+ { + return assignProxies({ + ...args, + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true, + }), + }); + }} + wallet={wallet} + > + + + + unassignProxies({ + ...args, + onInstructions: onInstructions(provider, { + useFirstEstimateForAll: true, + }), + }) + } + wallet={wallet} + > + + +
{infoCard}
+
+
+
+ + + ); +} diff --git a/src/components/ProxySearch.tsx b/src/components/ProxySearch.tsx new file mode 100644 index 0000000..668272c --- /dev/null +++ b/src/components/ProxySearch.tsx @@ -0,0 +1,73 @@ +import { ellipsisMiddle } from "@/lib/utils"; +import { PublicKey } from "@solana/web3.js"; +import { useDebounce } from "@uidotdev/usehooks"; +import React, { useMemo, useState } from "react"; +import { AutoComplete } from "./ui/autocomplete"; +import { Loader2 } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { + proxiesQuery, + useHeliumVsrState, +} from "@helium/voter-stake-registry-hooks"; +import { EnhancedProxy } from "@helium/voter-stake-registry-sdk"; + +export const ProxySearch: React.FC<{ + value: string; + onValueChange: (value: string) => void; +}> = ({ value, onValueChange }) => { + const [input, setInput] = useState(value); + const debouncedInput = useDebounce(input, 300); + const { voteService } = useHeliumVsrState(); + const { data: resultPaged, isLoading } = useInfiniteQuery( + proxiesQuery({ + search: debouncedInput || "", + amountPerPage: 20, + voteService, + }) + ); + + const result = useMemo(() => { + const resultsAsOptions = + resultPaged?.pages.flat().map((r) => { + return { + value: r.wallet, + label: `${r.name} | ${ellipsisMiddle(r.wallet)}`, + }; + }) || []; + if (isValidPublicKey(debouncedInput)) { + resultsAsOptions.push({ + value: debouncedInput || "", + label: debouncedInput || "", + }); + } + return resultsAsOptions; + }, [resultPaged, debouncedInput]); + const selectedOption = useMemo(() => { + return result?.find((r) => r.value == value); + }, [result, value]); + + if (value && !selectedOption) { + return ; + } + + return ( + onValueChange(v.value)} + value={selectedOption} + onInputChange={setInput} + /> + ); +}; + +function isValidPublicKey(input: string | undefined) { + try { + new PublicKey(input || ""); + return true; + } catch (e) { + return false; + } +} diff --git a/src/components/RealmProposal.tsx b/src/components/RealmProposal.tsx index 9f0104f..8853e6f 100644 --- a/src/components/RealmProposal.tsx +++ b/src/components/RealmProposal.tsx @@ -44,77 +44,83 @@ export const RealmProposal: FC<{

Voting is Closed

- - - - - - Back to Votes - -
- {Object.values(tags) - .filter((tag) => tag !== "tags") - .map((tag, i) => ( - 0, - } - )} +
+ +
+
+ + + - {tag} - - ))} + + Back to Votes + +
+ {Object.values(tags) + .filter((tag) => tag !== "tags") + .map((tag, i) => ( + 0, + } + )} + > + {tag} + + ))} +
+

{name}

+
+ +
+
+ +
+ + +
+
+ + {content.replace(name, "")} + +
+
+
+
+
-

{name}

- - -
-
- -
- - -
-
- - {content.replace(name, "")} - -
-
-
- - -
+
+ +
- - - +
+ +
); }; diff --git a/src/components/RevokeProxyButton.tsx b/src/components/RevokeProxyButton.tsx new file mode 100644 index 0000000..e296a5a --- /dev/null +++ b/src/components/RevokeProxyButton.tsx @@ -0,0 +1,58 @@ +import { useGovernance } from "@/providers/GovernanceProvider"; +import { useWallet } from "@solana/wallet-adapter-react"; +import { PublicKey } from "@solana/web3.js"; +import { Loader2 } from "lucide-react"; +import React, { useMemo } from "react"; +import { Button } from "./ui/button"; +import { RiUserReceivedFill } from "react-icons/ri"; +import { cn } from "@/lib/utils"; + +export const RevokeProxyButton = React.forwardRef< + HTMLButtonElement, + { + className?: string; + onClick?: () => void; + isLoading?: boolean; + wallet?: PublicKey; + } +>(({ wallet, className = "", onClick, isLoading = false }) => { + const { connected } = useWallet(); + const { loading, positions } = useGovernance(); + + const proxiedPositions = useMemo( + () => + positions?.filter( + (p) => + p.proxy && + !p.proxy.nextVoter.equals(PublicKey.default) && + (!wallet || p.proxy.nextVoter.equals(wallet)) + ), + [wallet, positions] + ); + + const disabled = !connected || loading || !proxiedPositions?.length; + + return ( + + ); +}); + +RevokeProxyButton.displayName = "RevokeProxyButton"; diff --git a/src/components/RevokeProxyModal.tsx b/src/components/RevokeProxyModal.tsx new file mode 100644 index 0000000..adbe758 --- /dev/null +++ b/src/components/RevokeProxyModal.tsx @@ -0,0 +1,167 @@ +import { useGovernance } from "@/providers/GovernanceProvider"; +import { PositionWithMeta } from "@helium/voter-stake-registry-hooks"; +import { PublicKey } from "@solana/web3.js"; +import { Loader2 } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { PositionItem } from "./AssignProxyModal"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; +import { useSolanaUnixNow } from "@helium/helium-react-hooks"; +import BN from "bn.js"; + +interface RevokeProxyModalProps { + onSubmit: (args: { positions: PositionWithMeta[] }) => Promise; + wallet?: PublicKey; +} + +export const RevokeProxyModal: React.FC< + React.PropsWithChildren +> = ({ onSubmit, wallet, children }) => { + const [open, setOpen] = useState(false); + + const { loading, positions } = useGovernance(); + const [selectedPositions, setSelectedPositions] = useState>( + new Set() + ); + const [isSubmitting, setIsSubmitting] = useState(false); + const proxiedPositions = useMemo( + () => + positions?.filter( + (p) => + p.proxy && + !p.proxy.nextVoter.equals(PublicKey.default) && + (!wallet || p.proxy.nextVoter.equals(wallet)) + ), + [positions, wallet] + ); + React.useEffect(() => { + console.log("proxiedPositions", proxiedPositions?.map(p => ({ + pubkey: p.pubkey.toBase58(), + proxy: p.proxy + }))); + }, [proxiedPositions]); + + const handleOnSubmit = async () => { + try { + const positionsByKey = positions?.reduce((acc, p) => { + acc[p.pubkey.toString()] = p; + return acc; + }, {} as Record); + setIsSubmitting(true); + if (positionsByKey) { + await onSubmit({ + positions: Array.from(selectedPositions).map( + (p) => positionsByKey[p] + ), + }); + } + + setOpen(false); + setSelectedPositions(new Set([])); + } catch (e: any) { + setIsSubmitting(false); + console.error(e) + toast(e.message || "Unable to Revoke proxy"); + } + }; + + const handleOpenChange = () => { + setIsSubmitting(false); + setOpen(!open); + }; + + const selectedAll = proxiedPositions?.length === selectedPositions.size; + + return ( + + {children} + + +
+

+ Revoke Proxies +

+
+ Select the positions you would like to revoke from this voter. +
+
+
+
Proxied Positions
+ +
+ {loading ? ( + <> +
+
Fetching Positions available to Revoke Proxy
+
+
+ +
+ + ) : ( +
+
+ {proxiedPositions?.map((position) => { + return ( + { + setSelectedPositions((sel) => { + const key = position.pubkey.toBase58(); + const newS = new Set(sel); + if (sel.has(key)) { + newS.delete(key); + return newS; + } else { + newS.add(key); + return newS; + } + }); + }} + /> + ); + })} +
+
+ )} +
+ + +
+
+
+ ); +}; diff --git a/src/components/SubNav.tsx b/src/components/SubNav.tsx new file mode 100644 index 0000000..7e9a31d --- /dev/null +++ b/src/components/SubNav.tsx @@ -0,0 +1,111 @@ +"use client"; + +import React from "react"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { FaBolt } from "react-icons/fa6"; +import { IoPerson } from "react-icons/io5"; +import { LuScrollText } from "react-icons/lu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Button } from "./ui/button"; +import { DropdownMenuLabel } from "@radix-ui/react-dropdown-menu"; +import { IconType } from "react-icons"; +import { HiDotsVertical } from "react-icons/hi"; +import { cn } from "@/lib/utils"; + +const icons: { [key: string]: IconType } = { + proposals: LuScrollText, + proxies: IoPerson, + positions: FaBolt, +}; + +export const SubNav: React.FC = () => { + const path = usePathname(); + const basePath = path.split("/").slice(0, 2).join("/"); + const currentPath = path.split("/")[2] || "proposals"; + + return ( +
+
+ + + + + Proposals + + + + + + Proxies + + + + + + Positions + + + +
+
+ + + + + + + + + + Proposals + + + + + + Proxies + + + + + + Positions + + + + + +
+
+ ); +}; diff --git a/src/components/VeTokensCallout.tsx b/src/components/VeTokensCallout.tsx index 1a7b80b..cbce7d8 100644 --- a/src/components/VeTokensCallout.tsx +++ b/src/components/VeTokensCallout.tsx @@ -2,6 +2,7 @@ import { usePrevious } from "@/hooks/usePrevious"; import { abbreviateNumber } from "@/lib/utils"; +import { useGovernance } from "@/providers/GovernanceProvider"; import { useAnchorProvider, useMint, @@ -10,16 +11,15 @@ import { import { HNT_MINT, IOT_MINT, MOBILE_MINT, toNumber } from "@helium/spl-utils"; import { calcPositionVotingPower, - getPositionKeys, - getRegistrarKey, + usePositionKeysAndProxies, usePositions, - useRegistrar, + useRegistrar } from "@helium/voter-stake-registry-hooks"; +import { getRegistrarKey } from "@helium/voter-stake-registry-sdk"; import { PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import Image from "next/image"; -import React, { FC, useMemo } from "react"; -import { useAsync } from "react-async-hook"; +import { FC, useMemo } from "react"; const VeTokenItem: FC<{ mint: PublicKey }> = ({ mint }) => { const provider = useAnchorProvider(); @@ -33,30 +33,35 @@ const VeTokenItem: FC<{ mint: PublicKey }> = ({ mint }) => { ); const { info: registrar } = useRegistrar(registrarKey); - const args = useMemo( - () => - wallet && - mint && - connection && { - wallet: wallet.publicKey, - mint, - provider, - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [wallet?.publicKey?.toBase58(), mint.toBase58(), connection, provider] - ); + const { voteService } = useGovernance(); + const { + positionKeys, + proxiedPositionKeys, + isLoading: loadingPositionKeys, + } = usePositionKeysAndProxies({ + wallet: wallet?.publicKey, + provider, + voteService, + }); - const { result, loading: loadingPositionKeys } = useAsync( - async (args: any | undefined) => { - if (args) { - return getPositionKeys(args); + // Assume that my positions are a small amount, so we don't need to say they're static + const { accounts: myPositions, loading: loadingMyPositions } = + usePositions(positionKeys); + // Proxied positions may be a lot, set to static + const { accounts: proxiedPositions, loading: loadingProxyPositions } = + usePositions(proxiedPositionKeys, true); + const loadingFetchedPositions = loadingMyPositions || loadingProxyPositions + const fetchedPositions = useMemo(() => { + const uniquePositions = new Map(); + [...(myPositions || []), ...(proxiedPositions || [])].forEach( + (position) => { + if (position) { + uniquePositions.set(position.publicKey.toBase58(), position); + } } - }, - [args] - ); - - const { loading: loadingFetchedPositions, accounts: fetchedPositions } = - usePositions(result?.positionKeys); + ); + return Array.from(uniquePositions.values()); + }, [myPositions, proxiedPositions]); const positions = useMemo( () => fetchedPositions?.map((fetched) => fetched.info), diff --git a/src/components/ViewPositionsButton.tsx b/src/components/ViewPositionsButton.tsx deleted file mode 100644 index a87da2c..0000000 --- a/src/components/ViewPositionsButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { FaBolt } from "react-icons/fa6"; -import Link from "next/link"; -import React, { FC } from "react"; -import { Button } from "./ui/button"; -import { usePathname } from "next/navigation"; -import classNames from "classnames"; - -export const ViewPositionsButton: FC<{ className: string }> = ({ - className = "", -}) => { - const path = usePathname(); - - return ( - - - - ); -}; diff --git a/src/components/VoteBreakdown.tsx b/src/components/VoteBreakdown.tsx index 36a3f5c..a67050c 100644 --- a/src/components/VoteBreakdown.tsx +++ b/src/components/VoteBreakdown.tsx @@ -1,5 +1,5 @@ import React, { FC, useMemo, useState } from "react"; -import { humanReadable } from "@/lib/utils"; +import { ellipsisMiddle, humanReadable } from "@/lib/utils"; import { PublicKey } from "@solana/web3.js"; import { useProposal, @@ -9,7 +9,7 @@ import { useRegistrar } from "@helium/voter-stake-registry-hooks"; import { useMint } from "@helium/helium-react-hooks"; import BN from "bn.js"; import { useVotes } from "@/hooks/useVotes"; -import { toNumber } from "@helium/spl-utils"; +import { toNumber, truthy } from "@helium/spl-utils"; import { Table, TableBody, @@ -24,14 +24,6 @@ import { FaChevronDown } from "react-icons/fa6"; import classNames from "classnames"; import Link from "next/link"; -const ellipsisMiddle = (wallet: string): string => { - const length = wallet.length; - const start = wallet.slice(0, 5); - const end = wallet.slice(length - 5, length); - const middle = "..."; - return start + middle + end; -}; - export const VoteBreakdown: FC<{ proposalKey: PublicKey; }> = ({ proposalKey }) => { @@ -80,13 +72,14 @@ export const VoteBreakdown: FC<{ }, [markers, decimals]); const csvData = useMemo(() => { - const rows = []; + const rows: string[][] = []; rows.push(["Owner", "Choices", "Vote Power", "Percentage"]); (groupedSortedMarkers || []).forEach((marker) => { const owner = marker.voter.toBase58(); const choices = marker.choices .map((c) => proposal?.choices[c].name) + .filter(truthy) .join(", "); const voteWeight = humanReadable(marker.totalWeight, decimals); @@ -97,7 +90,7 @@ export const VoteBreakdown: FC<{ .toNumber() .toFixed(2); - rows.push([owner, choices, voteWeight, percentage]); + rows.push([owner, choices, voteWeight || "", percentage]); }); const csvContent = rows @@ -141,35 +134,32 @@ export const VoteBreakdown: FC<{ return (
-
Voter Breakdown
- +

Voter Breakdown

+ Download as CSV
-

+

Note: For MOBILE/IOT subnetworks, this is shown as 1/10 of your vote power becuase the underlying contracts in spl-governance onlysupport 64-bits of precision

- - Download as CSV - - +
{groupedSortedMarkers && groupedSortedMarkers.length > displayCount && ( )} - OWNER - CHOICES - VOTE POWER - + + OWNER + + + CHOICES + + + VOTE POWER + + PERCENTAGE @@ -220,7 +210,8 @@ export const VoteBreakdown: FC<{